Skip to content

Add calendar aggregate commands - #21

Merged
narugo1992 merged 11 commits into
mainfrom
dev/issue-18-calendar-aggregate
May 12, 2026
Merged

Add calendar aggregate commands#21
narugo1992 merged 11 commits into
mainfrom
dev/issue-18-calendar-aggregate

Conversation

@narugo1992

@narugo1992 narugo1992 commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds animedex season and animedex schedule as top-level aggregate commands over AniList and Jikan.

The final implementation includes cross-source season item merging, timezone-aware schedule windows, a calendar-style TTY schedule renderer, fixture-backed merge adjudication for every 2010-2025 anime season, dedicated runtime dependency selftests, and PyInstaller packaging for transliteration and timezone dependency data. The latest review-fix commit also tightens fan-out HTTP-status extraction, surfaces merge diagnostics for rows that cannot enter merge analysis, and treats conflicting shared external IDs as a de-merge signal.

Demo

aggregate.gif

Examples And Expected Output

# Season JSON preserves the merged row plus per-source details.
animedex season 2024 spring --limit 5 --json --no-cache --jq '{count: (.items | length), first: (.items[0] | {title: .title.romaji, sources: (.sources | map(.backend)), jikan_score: .source_details.jikan.score.score})}'
# {"count":6,"first":{"title":"Kaijuu 8-gou","sources":["anilist","jikan"],"jikan_score":8.21}}

# TTY schedule output is grouped like a local calendar and keeps source IDs visible.
animedex schedule --day monday --source jikan --timezone +08:00 --limit 3 --no-cache
# Schedule (+08:00)
# Window: 2026-05-11 to 2026-05-12 (exclusive)
#
# Monday, 2026-05-11
#   00:00  Shin Nippon History  [src: jikan]
#     IDs:
#       Jikan/MAL: 54871
#     Names:
#       Japanese: 新ニッポンヒストリー
#     Info:
#       Status: Currently Airing
#       Source material: Original
#       Rating: G - All Ages
#     Tags:
#       - TV
#   17:25  Puzzle & Dragon  [src: jikan]

# JSON schedule output remains structured for jq and agents.
animedex schedule --day monday --source jikan --timezone +08:00 --limit 3 --json --no-cache --jq '{timezone: .timezone, first: (.items[0] | {title, local_time, airing_at, source_material: .details.source_material})}'
# {"timezone":"+08:00","first":{"title":"Shin Nippon History","local_time":"00:00","airing_at":"2026-05-11T00:00:00+08:00","source_material":"Original"}}

Implementation Notes

  • season merges likely identical AniList/Jikan rows into one MergedAnime by default, preserving source attribution through sources, per-backend records, source_details, and source_payloads instead of hiding provenance.
  • The merge rule uses external IDs first, then combines normalized title keys, ASCII/transliteration variants from anyascii, jaconv, and unidecode, fuzzy title comparison, media type, season, year, episode count, status, and date context. It does not call an LLM at runtime.
  • Shared external-ID conflicts now block fuzzy grouping, so two records with the same title context but disagreeing mal IDs remain two single-source entries instead of silently dropping one ID value.
  • Rows whose rich to_common() projection fails now produce AggregateResult.merge_diagnostics[] entries with backend, id, reason, and message, while the row remains visible as passthrough output.
  • schedule accepts --timezone with local, UTC/Z, IANA names, fixed offsets such as +08:00 or UTC+8, and dateutil timezone strings such as CST-8; TTY output is grouped by local date like a small calendar, while --json remains structured JSON.
  • Jikan schedule aggregation queries adjacent source weekdays when needed, converts JST broadcast times into the requested timezone, filters rows to the requested local day/window, and uses tzdata plus a fixed JST fallback so Windows and Linux agree.
  • Runtime dependency selftest rows are direct animedex selftest entries such as testing python_dateutil library, testing anyascii library, testing jaconv library, testing unidecode library, and testing tzdata library, with one smoke function per requirement.
  • The PyInstaller spec generator includes anyascii and tzdata data, and PyInstaller's unidecode hook is exercised by make build && make test_cli so frozen binaries pass the same dependency selftests as the source checkout.
  • romkan was avoided because its current sdist imports the removed imp module during build and fails CI installation on Python 3.12+.

Fixtures And Merge Baseline

  • Captured and bundled 2010-2025 season fixtures for AniList and Jikan: 64 AniList season-matrix fixtures and 64 Jikan season-matrix fixtures.
  • Added aggregate merge evaluation fixtures under test/fixtures/aggregate/season_matrix/, including 8 adjudication shards, 64 candidate snapshots, expected_matches.json, and a README documenting regeneration and adjudication.
  • The checked merge baseline is expected=1461 predicted=1461 false_negative=0 false_positive=0.
  • Added extra Jikan schedule weekday fixtures so timezone conversion can be tested against adjacent-day windows, not only the direct source weekday.

Review-Fix TDD Notes

  • B1 is covered by false-positive cases for limit=200 reached, per_page=400 rejected, and season 2024 spring, plus true-positive cases such as HTTP 429, HTTP/1.1 503, AniList 429, and Jikan 404.
  • B2 is covered by a synthetic rich row whose to_common() raises and now produces merge_diagnostics[] instead of silently disappearing into passthrough.
  • B3 is covered by two synthetic records with the same title context but conflicting mal IDs; the result remains two separate merged entries.
  • I also ran a local old-behavior proof script that recreated the previous regex, silent passthrough, and ids.setdefault merge behavior; all three new blocking-regression assertions fail against that old behavior.

Verification

  • PATH="$PWD/venv/bin:$PATH" make format -> passed
  • PATH="$PWD/venv/bin:$PATH" pytest test/agg/test_fanout.py test/agg/test_calendar.py test/entry/test_aggregate_calendar.py::test_partial_failure_returns_success_with_stderr -q -> 25 passed
  • Local old-behavior TDD proof script -> B1 false-positive regression test fails against old regex; B2 diagnostic regression test fails against old silent passthrough; B3 conflict regression test fails against old setdefault merge
  • PATH="$PWD/venv/bin:$PATH" make test -> 2781 passed, 83 skipped, total coverage 99%
  • PATH="$PWD/venv/bin:$PATH" make rst_auto -> passed
  • PATH="$PWD/venv/bin:$PATH" python -m animedex --help -> passed
  • PATH="$PWD/venv/bin:$PATH" python -m animedex season --help -> passed
  • PATH="$PWD/venv/bin:$PATH" python -m animedex schedule --help -> passed
  • PATH="$PWD/venv/bin:$PATH" python -m animedex selftest -> 121 passed, 0 failed
  • PATH="$PWD/venv/bin:$PATH" make build && PATH="$PWD/venv/bin:$PATH" make test_cli -> 4 passed, 0 failed

@codecov

codecov Bot commented May 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (9257243) to head (73fa6bb).

Additional details and impacted files
@@             Coverage Diff             @@
##              main       #21     +/-   ##
===========================================
  Coverage   100.00%   100.00%             
===========================================
  Files          105       112      +7     
  Lines         7177      8817   +1640     
===========================================
+ Hits          7177      8817   +1640     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@narugo1992

Copy link
Copy Markdown
Contributor Author

I aligned the cross-source season merge by treating AniList and Jikan as two evidence streams for the same anime entry instead of two separate lists to print side by side.

The merge pipeline is deterministic and does not depend on an LLM. It first prefers hard identifiers when they exist, then falls back to a weighted comparison over several fields together: normalized titles, transliterated title variants, media type, season, year, episode count, status, and nearby date context. The title normalization uses multiple libraries on purpose (anyascii, jaconv, and romkan) so cross-language and romanized variants do not collapse onto a single fragile heuristic. When the two sources disagree, the item only merges if the combined signal is strong enough; otherwise it stays split so provenance remains honest.

For schedule output, I kept JSON as structured data and changed the TTY path to a calendar-like view in the selected timezone. Jikan schedule lookups now probe adjacent source weekdays when the target local window crosses midnight, then convert the broadcast time into the requested zone before filtering into the final day bucket. That keeps the display aligned with the user's local notion of a day rather than the source site's JST wall clock.

Testing covered four layers:

  • full repository regression suite: PATH="./venv/bin:$PATH" make test -> 2755 passed, 83 skipped
  • CLI and packaging smoke: PATH="./venv/bin:$PATH" make build && PATH="./venv/bin:$PATH" make test_cli
  • selftest coverage for every runtime dependency row, including the new transliteration libraries
  • merge adjudication against the captured 2010-2025 season matrix, which finished at expected=1461 predicted=1461 false_negative=0 false_positive=0

I also re-rendered the demo GIF from the updated tape and uploaded it so the PR body reflects the final terminal output, not an intermediate state.

@narugo1992

Copy link
Copy Markdown
Contributor Author

CI exposed an install-time issue before the tests started: romkan==0.2.1 builds from sdist and its setup path imports the removed imp module, so Python 3.12 and 3.13 fail during dependency installation.

I replaced romkan with unidecode for the third transliteration signal. The runtime merge rule still combines multiple independent normalizers (anyascii, jaconv, and unidecode), and the 2010-2025 season matrix still evaluates cleanly at expected=1461 predicted=1461 false_negative=0 false_positive=0.

Additional verification after the replacement:

  • local Python 3.12.1 CI-style install: passed
  • local Python 3.13.1 CI-style install: passed
  • PATH="./venv/bin:$PATH" make test: 2755 passed, 83 skipped
  • PATH="./venv/bin:$PATH" make build && PATH="./venv/bin:$PATH" make test_cli: 4 passed, 0 failed

@narugo1992
narugo1992 force-pushed the dev/issue-18-calendar-aggregate branch from 34e712f to 15eb97f Compare May 11, 2026 15:04
Add tzdata-backed Windows timezone fallback, expand calendar rendering coverage, and add dedicated dependency smoke coverage so season and schedule aggregation stay deterministic across platforms.
@narugo1992

Copy link
Copy Markdown
Contributor Author

Implementation and verification details for the cross-site alignment work:

The season aggregate treats AniList and Jikan as two evidence streams for the same anime entry. Runtime merging is deterministic and does not call an LLM. The merge rule first checks hard identifiers, especially MAL IDs surfaced through each backend mapper. When hard IDs are absent, it scores multiple signals together instead of trusting one field: normalized title keys, transliterated variants from anyascii, jaconv, and unidecode, fuzzy title similarity, media format, anime season, season year, episode count, status, and nearby start-date context. A row only merges when the combined score crosses the threshold; otherwise both source rows remain separate with their own provenance.

Source attribution stays explicit after merging. MergedAnime keeps the merged title and combined IDs for the human-facing row, but it also retains per-backend records and source tags so JSON consumers can inspect exactly what AniList and Jikan contributed. The TTY renderer prints combined source markers, and JSON keeps the structured source/provenance fields rather than flattening the disagreement away.

The 2010-2025 season matrix was used to tune and lock the rule. I captured AniList and Jikan fixtures for all 64 seasons in that range, generated candidate comparisons, and stored adjudicated expected matches under test/fixtures/aggregate/season_matrix/. The evaluator is now part of the unit suite and currently reports expected=1461 predicted=1461 false_negative=0 false_positive=0, so the rule is fixed by fixture evidence rather than by intuition or runtime model calls.

For schedule alignment, AniList schedule rows already carry instants, while Jikan exposes broadcast weekday/time data in source-site terms. The aggregate now resolves the requested target timezone, queries adjacent Jikan weekdays when a local window can cross the source-site day boundary, converts Jikan broadcast times from JST into the target timezone, and only then filters into the requested local calendar window. The latest hardening commit adds tzdata plus an explicit JST fallback so Windows runners without a system IANA timezone database compute the same converted day/time as Linux and macOS.

Verification run after the final hardening commit:

  • PATH="./venv/bin:$PATH" make format
  • PATH="./venv/bin:$PATH" ./venv/bin/python -m pytest test/agg/test_calendar.py test/render/test_tty.py test/diag/test_selftest.py -q -> 55 passed
  • PATH="./venv/bin:$PATH" make test -> 2771 passed, 83 skipped, total coverage 100%
  • merge evaluator in the unit suite -> expected=1461 predicted=1461 false_negative=0 false_positive=0
  • PATH="./venv/bin:$PATH" make rst_auto
  • ./venv/bin/animedex --help
  • ./venv/bin/animedex season --help
  • ./venv/bin/animedex schedule --help
  • ./venv/bin/python -m animedex.policy.lint animedex -> animedex docstring lint: OK
  • PATH="./venv/bin:$PATH" make build && PATH="./venv/bin:$PATH" make test_cli -> 4 passed, 0 failed

I also corrected the earlier comment that mentioned romkan: the final dependency set is anyascii, jaconv, unidecode, and tzdata. romkan was intentionally removed because its current sdist fails install on Python 3.12+ by importing the removed imp module during setup.

@narugo1992 narugo1992 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(withdrawn — superseded by a corrected review on this PR after the maintainer confirmed cross-source merging is intended behaviour)

@narugo1992 narugo1992 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(withdrawn — superseded by a deeper audit review on this PR)

@narugo1992 narugo1992 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(withdrawn — superseded by a follow-up review on this PR after the address-findings + patch-coverage commits)

Comment thread animedex/agg/_fanout.py Outdated
Comment thread animedex/agg/calendar.py
Comment thread animedex/models/aggregate.py
Comment thread animedex/agg/calendar.py
Comment thread animedex/agg/calendar.py
Tighten aggregate fan-out status parsing so status-shaped parameters such as limit=200 are not reported as HTTP statuses.

Surface season merge projection failures through merge_diagnostics and split fuzzy merge candidates that disagree on shared external IDs.

Keep python-dateutil because aggregate schedule timezones now accept dateutil timezone strings in addition to IANA names and fixed offsets. Keep jaconv for kana and width normalization, and keep both anyascii and unidecode because their CJK and Hangul segmentation differs enough to improve cross-language title recall before the calibrated merge score applies.
Comment thread animedex/agg/_fanout.py Outdated
Comment thread animedex/agg/calendar.py
Comment thread animedex/models/aggregate.py
Comment thread animedex/agg/calendar.py
Comment thread animedex/agg/calendar.py
@narugo1992

Copy link
Copy Markdown
Contributor Author

Review follow-up for fa984ee:

  • B1 fixed: _HTTP_STATUS_RE now requires explicit status context. Added false-positive regression coverage for limit=200 reached, per_page=400 rejected, and season 2024 spring, plus true-positive coverage for HTTP/status/backend-prefixed failures.
  • B2 fixed: season merge projection failures now log at debug level and surface through AggregateResult.merge_diagnostics[] with backend, id, reason, and message; the catch is narrowed to ValueError, AttributeError, and KeyError.
  • B3 fixed: conflicting shared external IDs now prevent fuzzy grouping and _merge_group() has a final conflict guard, so conflicting mal IDs remain separate rows instead of silently dropping one value.
  • S1/S2 fixed: _title_key_variants() now documents why jaconv, anyascii, and unidecode all stay, the commit body includes dependency justification, and merge thresholds now have calibration notes tied to the 2010-2025 adjudicated baseline.
  • S3/S4/S6 also addressed: _choose_merged_title() documents AniList/Jikan preference, dict-shaped fan-out returns support items/data or raise upstream-shape, and the season matrix corpus now has a README for regeneration/adjudication.

TDD check: I verified the new blocking regression assertions fail against a local recreation of the old behaviors: the previous broad regex, the previous silent to_common() passthrough, and the previous ids.setdefault() conflict drop.

Not changed in this PR:

  • S5 O(N²) optimization is intentionally deferred. The current PR still defaults to low per-source limits, and the correctness fixes were kept separate from indexing/performance work.
  • S7 slow/calibration test split is intentionally deferred. The corpus-wide matcher test remains in the default suite for this PR because it is the main guardrail for the merge rule being reviewed here.

Latest local verification:

  • PATH="$PWD/venv/bin:$PATH" make format
  • PATH="$PWD/venv/bin:$PATH" pytest test/agg/test_fanout.py test/agg/test_calendar.py test/entry/test_aggregate_calendar.py::test_partial_failure_returns_success_with_stderr -q -> 25 passed
  • PATH="$PWD/venv/bin:$PATH" make test -> 2781 passed, 83 skipped, total coverage 99%
  • PATH="$PWD/venv/bin:$PATH" make rst_auto
  • PATH="$PWD/venv/bin:$PATH" python -m animedex --help
  • PATH="$PWD/venv/bin:$PATH" python -m animedex season --help
  • PATH="$PWD/venv/bin:$PATH" python -m animedex schedule --help
  • PATH="$PWD/venv/bin:$PATH" python -m animedex selftest -> 121 passed, 0 failed
  • PATH="$PWD/venv/bin:$PATH" make build && PATH="$PWD/venv/bin:$PATH" make test_cli -> 4 passed, 0 failed

@narugo1992

Copy link
Copy Markdown
Contributor Author

TDD coverage follow-up pushed in b86d269.

What changed:

  • Added focused regression coverage for the aggregate/calendar helper branches touched by the review fixes: payload fallback handling, multilingual title extraction, raw tag/studio extraction, type tags, schedule projection passthrough, title/context scoring edge cases, merged multilingual details, and external-ID conflict guards.
  • Added focused TTY renderer coverage for the calendar/merged output paths that were introduced in this PR: nested tree rendering, compact list truncation, schedule ID rendering from core and backend details, merged ID rendering from records/details, and fallback title/airing detail shapes.
  • Added timezone utility edge coverage for keyed labels and prefixed invalid offset errors.
  • Removed an unreachable merged-title list branch in the TTY renderer after the new tests showed _titles_tty_view() always returns scalar display values.

TDD/blocking validation:

  • The blocking failure was Codecov patch coverage on the reviewed aggregate changes.
  • After adding the tests, the local diff-coverage check over the PR diff reports 1598/1598 changed executable lines covered and missing changed executable lines 0.
  • This makes the previous blocking condition reproducible locally and verifies it is fixed before relying on CI.

Local validation:

  • make format
  • pytest test/render/test_tty.py -q -> 22 passed
  • make test -> 2792 passed, 83 skipped, total coverage 100%
  • local PR diff coverage script -> missing changed executable lines 0
  • make rst_auto
  • python -m animedex selftest -> 121 passed, 0 failed
  • make build && make test_cli -> binary smoke 4 passed, 0 failed
  • git diff --check

@narugo1992 narugo1992 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (round 4, post fix commits): all 3 blocking findings resolved + 4 deeper non-blocking notes

The fa984ee1 ("address aggregate review findings") and b86d269f ("raise aggregate patch coverage") commits address every blocking finding from the prior strict round, and most of the non-blocking suggestions. Going through them point by point:

Prior findings — resolution status

  • B1 — _HTTP_STATUS_RE false-positives ✅ resolved. The regex at animedex/agg/_fanout.py:35–45 now requires a status-introducing token (HTTP, status, returned, response, or a backend name) before the 3-digit number. The new test test_http_status_requires_status_context in test/agg/test_fanout.py pins the false-positive cases (limit=200 reached, etc.) to None and the true-positive cases to the correct status.

  • B2 — _to_common_anime silent except ✅ resolved. _to_common_anime now delegates to _to_common_anime_with_diagnostic at line 790. The except is narrowed from bare Exception to (ValueError, AttributeError, KeyError); the failure is logged at logging.DEBUG; a structured diagnostic ({backend, id, reason: "to-common-failed", message}) is returned alongside None. AggregateResult.merge_diagnostics is the new envelope field that surfaces every such diagnostic. test_merge_season_items_reports_to_common_failures covers the path.

  • B3 — ids.setdefault silent conflict ✅ resolved with a two-layer defence: _group_has_external_id_conflict filters at grouping time so disagreeing rows never enter the same group, and _set_id at line 1217 raises ValueError with a precise message if the grouping-layer check ever lets a conflict slip through. _external_id_conflicts returns structured {key, left_backend, left_value, right_backend, right_value} records for downstream consumers. test_merge_season_items_splits_external_id_conflicts covers the path.

  • S1 — dual transliterator justification ✅ resolved. _title_key_variants now carries a substantial docstring with concrete CJK / Hangul examples (怪獣8号GuaiShou8Hao via anyascii vs Guai Swu 8Hao via unidecode; 마녀와 야수 produces different word boundaries). The commit body for fa984ee1 adds the corresponding requirements.txt justification paragraph naming each new dependency's role. Also picked up python-dateutil as a fourth dep, justified by the schedule timezone string support — fine addition.

  • S2 — magic threshold calibration ✅ resolved. The calibration note block above _MERGE_THRESHOLD = 70 at animedex/agg/calendar.py:37 states the recall and precision targets (>= 95% / >= 99%), the corpus the thresholds were tuned against, the directional trade-off when widening or narrowing, and the workflow for re-running tools/merge_eval/evaluate_rule.py.

  • S3 — _choose_merged_title anilist primary ✅ resolved. The function at line 998 has a docstring stating the preference order and the rationale ("AniList's romaji/native/English title block is the most consistent title schema across the 2010-2025 season corpus").

  • S4 — _normalise_items silent shape wrap ⚠️ partially resolved. The function at animedex/agg/_fanout.py:52 now explicitly supports dict-shaped envelopes by reading value["items"] or value["data"], and raises ApiError(reason="upstream-shape") when neither key is present on a dict. But the final fall-through at line 75 (return [value] for objects without a .rows attribute) is still a silent wrap. See S_new3 below.

  • S6 — fixture corpus README ✅ resolved. test/fixtures/aggregate/season_matrix/README.md documents the regeneration workflow including tools/merge_eval/build_candidates.py invocation, the manual adjudication steps via build_adjudication_inputs.py + combine_adjudication.py, and proxy-credential hygiene.

  • S5 — O(N²) merge perf not addressed (was optional; still optional).

  • S7 — pytest.mark.slow for adjudication test not addressed (was optional; still optional).

New findings from the deeper audit

The fix commits land a lot of new code: 882 added lines in calendar.py, a fresh animedex/utils/timezone.py module (152 lines), 499 lines in render/tty.py, and a tools/fixtures/prewarm_aggregate_cache.py. A fresh pass across that surface surfaces four more non-blocking items.

  • S_new1 — merge_diagnostics envelope field has no TTY stderr surface. The JSON envelope correctly carries merge_diagnostics when a row falls out of merge analysis, and the CLI's JSON path renders it. But _report_failures at animedex/entry/aggregate.py:58 only iterates over result.failed_sources (per-source upstream failures) and emits one stderr inform line per failed source. It does not iterate result.merge_diagnostics, so a TTY user whose season command encountered to_common() failures (a real bug in a backend's projection, schema drift, etc.) sees the rendered rows but no stderr signal that other rows were dropped from merge analysis. This is asymmetric with the per-source-failure inform path: source-level failures get stderr inform; row-level merge skips do not. Action: extend _report_failures to also emit one stderr inform line per merge diagnostic, e.g. "merge diagnostic: anilist:154587 fell back to passthrough (to_common-failed: AttributeError: ...); continuing". The body counts partial-failure semantics that keep healthy rows on stdout and emit stderr inform as the §0 contract for fan-out; the same contract should apply to merge-stage failures one layer deeper.

  • S_new2 — _set_id raises ValueError but no caller catches it. The raise at animedex/agg/calendar.py:1224 is the second defensive layer behind _group_has_external_id_conflict. The first layer (grouping-time pre-check) compares record.ids key sets and should keep _set_id from ever seeing a conflict — but _set_id operates on a wider key space than _external_id_conflicts examines: it also writes IDs derived from record.id's "prefix:value" split (line 1235–1238) and from per-backend fallback. If a rich-model mapper ever produces inconsistent records (e.g. record.ids = {"anilist": "999"} while record.id = "anilist:154587"), _set_id raises and the exception propagates all the way up: _merge_season_items does not catch it (line 1283 list comprehension), season() does not catch it (line 1338), and animedex/entry/aggregate.py:43 only catches ApiError. End state: an uncaught ValueError traceback to the CLI user. Action: either (a) wrap the list comprehension in _merge_season_items with a try/except that converts the raise into a structured merge_diagnostics entry and re-splits the group into single-source entries, or (b) replace the raise in _set_id with a record into a conflicts list that flows into MergedAnime.id_conflicts (consistent with the structural-conflict shape _external_id_conflicts already returns). Either resolution keeps the failure observable through the envelope and prevents a CLI traceback.

  • S_new3 — _normalise_items final fall-through silently wraps unknown shapes. Line 75 of animedex/agg/_fanout.py reads return [value] for any source return value that isn't None, list, tuple, dict, or an object exposing .rows. The dict path now correctly raises upstream-shape; the unknown-object path still silently wraps a single object as a single-row list. Today's backends never hit this path, but a future backend whose return value is a custom dataclass with no .rows accessor will become a silent single-row source. Action: either (a) raise ApiError(reason="upstream-shape", message=f"aggregate source returned unsupported shape: {type(value).__name__}"), or (b) keep the wrap but add a comment naming the contract the project relies on (e.g. "All current backends return list-of-Anime or a wrapper with .rows; this fall-through accepts a single scalar as a one-row source, which is the legacy shape from _jikan_rows returning early; revisit when a new backend lands"). The (a) form is cleaner and consistent with the dict-shape raise immediately above.

  • S_new4 — unidecode not listed in PyInstaller hidden imports / package datas. tools/generate_spec.py:99 explicitly hides anyascii._data because anyascii lazy-loads its transliteration tables under a resource-only sub-package the PyInstaller static analyser doesn't reach. unidecode has the same lazy-load pattern (from Unidecode import x0041 per code-point block, via __import__), but no equivalent hidden-import entry exists in the spec. The collect_submodules('animedex') call at the spec template's top picks up animedex's own submodules but not unidecode's. Today's make build && make test_cli passes — likely because PyInstaller's stdlib collection happens to grab unidecode submodules through its own scan — but the asymmetry with anyascii is fragile: a future PyInstaller upgrade that tightens dynamic-import handling could silently break unidecode("怪獣") in the frozen binary. Action: add "unidecode" and "unidecode.util" to HIDDEN_IMPORTS and "unidecode" to PACKAGE_DATAS (alongside anyascii), with a comment explaining the parallel justification. Verify after by running make build && make test_cli and grepping the frozen-binary directory tree for Unidecode/x*.py data files.

Process note (third occurrence; recording only)

Issue #18's 06:39Z self-reply ("Maintainer sign-off received in the working thread") remains the same 4-minute self-handshake pattern PR #17 first flagged. This is the third PR where the pattern appears. The maintainer eventually confirmed direction after the fact in both cases, so no harm done — but the §15.5 sign-off contract is not being honoured, and the next occurrence may not have the lucky direction-was-correct outcome. Worth tightening §15.5's wording in a separate hygiene PR so "Sign-off received" requires either a maintainer reply (someone other than the proposer) with a known marker, or an explicit out-of-band record (linked elsewhere in the issue trail). Not actionable on this PR.

Conclusion

The fix completeness is high — all three prior blocking findings are resolved with matching regression tests, the dual-transliterator and threshold-calibration docstrings land cleanly, and the fixture-corpus README closes the maintenance trail. The four deeper non-blocking items above are stage-two polish: merge_diagnostics deserves a stderr surface for symmetry with source-level partial-failure (S_new1), the _set_id raise path needs a catch boundary or a structural-conflict alternative (S_new2), the _normalise_items final fall-through should raise or be explicitly documented (S_new3), and the PyInstaller spec should hide unidecode data tables like anyascii already does (S_new4).

S_new2 is the most pressing because it's a latent crash path that bypasses the typed-error contract. The other three are robustness/symmetry improvements that can land in this PR or a follow-up. None individually block the merge — combined, they're the right shape for one more fix commit.

event=COMMENT because GitHub blocks self-REQUEST_CHANGES; treat the framing as a follow-up change request per §15.6.

narugo1992

Comment thread animedex/entry/aggregate.py
Comment thread animedex/agg/calendar.py Outdated
Comment thread animedex/agg/_fanout.py Outdated
Comment thread tools/generate_spec.py
Surface merge diagnostics through the CLI stderr path, keep merged rows inspectable when their internal external-id map disagrees, and reject unsupported aggregate fanout shapes with a typed upstream-shape error.

Also pin the unidecode package and utility module in the generated PyInstaller spec so transliteration resources stay available after freezing.
AniList returns HTTP 500 when the airingSchedules query sends optional mediaId and notYetAired variables as explicit nulls for date-window schedule lookups. Build the variables map first and drop unset optional filters while preserving explicit false and numeric values.

Regression coverage now asserts the Python API and top-level schedule CLI send the corrected GraphQL variable shape through the real HTTP transport seam.

@narugo1992 narugo1992 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review on c2ae80f7 + 3316503f + 4c58b12b — all four v4 findings resolved, ready to merge

The three new commits address every non-blocking item from the prior round, often with extras I didn't ask for. This is the cleanest fix round on this PR so far.

S_new1 — merge_diagnostics no TTY stderr surface — ✅ resolved

animedex/entry/aggregate.py:66 introduces _report_merge_diagnostics, called from _finish immediately after _report_failures. The implementation distinguishes the two diagnostic kinds explicitly:

  • external-id-conflict"merge diagnostic: {backend}:{id} kept with external id conflict ({message})" (the row is preserved with conflicts visible)
  • everything else → "merge diagnostic: {backend}:{id} dropped from merge analysis ({reason}: {message}); kept as passthrough row" (the row falls back to passthrough)

Two regression tests pin both branches (test_merge_diagnostics_are_reported_to_stderr for the to-common-failed shape, test_external_id_conflict_diagnostics_are_reported_to_stderr for the conflict shape). The §0 inform-not-gate symmetry with _report_failures is now complete.

S_new2 — _set_id raise propagating uncaught — ✅ resolved (structural alternative path chosen)

_set_id no longer raises. The signature gains backend and source keyword parameters; on conflict the helper appends a structured record to a function-local id_conflicts list and returns. MergedAnime gains an id_conflicts: List[Dict[str, Any]] = Field(default_factory=list) field carrying the conflict records through the envelope. _merge_season_items translates each conflict into a merge_diagnostics entry with reason: "external-id-conflict" so the stderr surface (S_new1) picks it up. The CLI now never sees an uncaught ValueError; the structural envelope is the single source of truth for disagreement signals.

This is the (a) alternative I suggested in v4 — cleaner than catch-at-boundary because the conflict structure unifies with _external_id_conflicts's existing shape. The regression test test_merge_season_items_reports_internal_id_conflicts_without_traceback constructs the previously-crashing inconsistent-mapper corner case (record.ids = {"anilist": "999"} vs record.id = "anilist:154587") and asserts (i) no traceback, (ii) the conflict surfaces structurally on the envelope, (iii) the diagnostic message reaches merge_diagnostics.

S_new3 — _normalise_items silent fall-through — ✅ resolved

animedex/agg/_fanout.py:75 now raises ApiError(reason="upstream-shape", message="aggregate source returned unsupported shape: {type_name}") for any value that isn't None, list, tuple, dict, or .rows-bearing object. The test test_normalises_none_tuple_dict_and_rows_object is renamed (the _and_scalar part is dropped) and a new assertion checks that _normalise_items("x") raises upstream-shape with the expected message.

S_new4 — unidecode PyInstaller asymmetry — ✅ resolved with bonus defence in depth

tools/generate_spec.py:99 adds "unidecode" and "unidecode.util" to HIDDEN_IMPORTS, and "unidecode" to PACKAGE_DATAS, both with comment blocks explaining the lazy-load justification (parallel to anyascii). test/tools/test_generate_spec.py is a new file that asserts the spec generator carries both transliterators' data references — a regression-test catch in case a future spec refactor removes one accidentally.

The bonus: animedex/diag/selftest.py:409 adds _smoke_unidecode() that asserts both an ASCII case (unidecode("Pokémon") == "Pokemon") and a Japanese kana case (unidecode("ソードアート") == "so-doa-to"). Combined with the existing _smoke_anyascii at line 392 testing 怪獣8号GuaiShou8Hao, every transliterator the project depends on now has a frozen-binary-friendly smoke check. This goes beyond what S_new4 asked for and gives the project a runtime guarantee that PyInstaller upgrades won't silently break either library.

Extras I didn't ask for but appreciate

  • 3316503fanilist.airing_schedule filters out None variables before sending to GraphQL. Previously the function passed {"mediaId": None, "notYetAired": None, ...} to _gql; now it builds the variable dict and drops None entries. AniList tolerates either shape, but a clean request body makes wire-level inspection easier and avoids ambiguity if AniList ever distinguishes "not specified" from "null". A regression test (test_airing_schedule_omits_unset_filters in the anilist suite) pins the wire shape.
  • 4c58b12b — schedule test stability. The previous rsps.calls[0] was positional-ordering-dependent on which mocked response fired first; _anilist_graphql_requests(rsps) filters by method == "POST" + url == "https://graphql.anilist.co/" and asserts len(...) == 1 before reading. Replaces a brittle index assumption with a structural lookup. Good test-hygiene improvement.

Nit-level observations (truly optional, not actionable for this PR)

These are micro-issues that don't justify another fix round; flagging only so they have a recorded home.

  • _to_common_anime_with_diagnostic at animedex/agg/calendar.py:790 returns (None, None) (no diagnostic) when item lacks to_common entirely or its to_common() returns a non-Anime value. The narrowly-typed exception branch above does emit a diagnostic, but the type-mismatch branch silently falls through to passthrough. Today every rich-model returns Anime from to_common, so this is unreachable in practice; if a future backend's to_common() ever returns a wrong type, the silent drop reappears. A trivial improvement would be to emit a diagnostic like {"reason": "to-common-returned-non-anime"} in that branch — but it's defending against a contract violation that the rich-model lossless test suite would also catch. Not worth a fix commit.
  • MergedAnime.id_conflicts is a top-level field and core["id_conflicts"] is set to the same list inside the compact core dict at line 1252. JSON consumers read the same data twice; harmless but slightly redundant. If you happen to be in the file for an unrelated reason, picking one location is cleaner.

Process note (final reinforcement, recorded only)

No new self-handshake on this round; the conversation flow on issue #18 has been quiet since the maintainer's direction confirmation. The §15.5 wording-tightening proposal stands as a future hygiene item but is unrelated to this PR.

Conclusion

Ready to merge. The fix completeness across the four review rounds on this PR has gone from "spec-violating + uncaught crash paths + silent drops" through "structurally correct but with documentation gaps and asymmetric surfaces" to "every diagnostic surfaces both as structured envelope field and as TTY stderr inform; every silent fall-through path either raises or is documented; both transliterator backends have frozen-binary smoke coverage". That's the right shape for this PR's complexity.

event=COMMENT because GitHub blocks self-APPROVE; treat the framing as approval-in-spirit per §15.6.

narugo1992

@narugo1992 narugo1992 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review on 73fa6bb6 — schedule timeline render is a clean visual polish, ready to merge

This commit reshapes the schedule TTY output into a left-gutter timeline (Unicode between time-of-day and the row title, continued vertically across each day's rows) and adds a graceful ASCII fallback (|) when the destination stream cannot encode the Unicode box-drawing character. It also threads an optional stream parameter through render_tty / _to_tty_text / _format_schedule_calendar_tty so callers can opt the renderer into stream-aware glyph selection.

Audit

Looked at the diff line by line and traced the new code paths against the existing renderer / dispatcher seams. Everything checks out:

  • _stream_supports_text(stream, text) boundary semantics are correct. stream is None returns True; encoding is None returns True; a broken codec (e.g. "no-such-codec-xyz") returns False because text.encode(encoding) raises LookupError, which the except (LookupError, UnicodeEncodeError) catches. Verified empirically against the helper in a fresh Python shell. The Unicode-by-default-when-uncertain stance is the right one for the render_tty direct-call surface that doesn't pass a stream — the explicit-stream callers (the two production callers below) carry the actual encoding context.

  • All production callers thread stream. animedex/entry/aggregate.py:54 passes sys.stdout; animedex/entry/_cli_factory.py:80 passes its caller-provided stream. render_for_stream at animedex/render/tty.py:966 passes the stream it was called with. The only path that defaults to stream=None is direct render_tty(model) invocations from tests / library users; for those the Unicode default is safe because if the eventual print() target can't encode the char, the user gets a UnicodeEncodeError they can act on (the project's only legitimate avoidance is via the explicit stream-aware path).

  • _render_schedule_timeline_tree is a clean reshape. Renders sections into a local io.StringIO, then prepends every line with " " * 6 + timeline + " ". The two-pass approach keeps _render_tree's column math independent of the gutter — important because that helper is also called outside the schedule path. The blank-timeline line between rows (line 851: if index: print(f"{' ' * 6}{timeline}", file=out)) is the right visual continuation.

  • ASCII fallback regression test pins the fallback shape. test_calendar_falls_back_to_ascii_timeline_when_stream_cannot_encode_unicode constructs an AsciiStream with encoding="ascii" and asserts (a) 01:00 | First Row appears, (b) the inter-row continuation |\n02:00 | Second Row is present, (c) is absent from the output. This is the right shape for the fallback contract.

  • Existing tests updated symmetrically. The five existing schedule-TTY tests (test_calendar_* family) all replace the old "01:00 Title" two-space-separated assertions with the new "01:00 │ Title" shape. No assertion was dropped silently.

  • The non-schedule render paths are untouched. _format_anime_tty, _format_character_tty, _format_merged_anime_tty, etc. don't take a stream argument and don't need one (they don't use box-drawing glyphs). The single-row _format_airing_schedule_tty also doesn't use the timeline character — it's only used when an AiringScheduleRow is rendered outside a calendar context. That asymmetry is fine: timeline is a per-day grouping convention, not a per-row decoration.

  • §14 backref grep returns zero across animedex/agg/, animedex/entry/aggregate.py, animedex/models/aggregate.py, animedex/utils/, and the touched animedex/render/tty.py.

One nit-level observation (not actionable)

_stream_supports_text(None, ...) and _stream_supports_text(stream_with_encoding_None, ...) both return True via separate branches. They are indirectly covered by the Unicode-default assertions in the existing tests (which pass stream=None implicitly), but neither has a direct unit test asserting _stream_supports_text(None, "│") is True. Trivial — the helper is six lines and the existing assertions cover the effect — but if you happen to be in test/render/test_tty.py for an unrelated reason, a one-line assert _stream_supports_text(None, "│") is True would close the symmetry with the existing fallback assertion. Not worth a separate fix commit.

Conclusion

Ready to merge. The timeline render is a real readability improvement (multi-row schedule output now reads as a coherent day-of-week column rather than a flat list), the ASCII fallback handles non-UTF-8 terminals correctly without requiring any caller-side awareness, and the stream-parameter threading is backward compatible. No new issues surfaced from the deep audit.

For the running record: the four review rounds on this PR have brought it from spec-mismatched + uncaught crash paths + silent drops (round 1) through structural fixes for those (rounds 2–3) to TTY symmetry, ASCII-stream graceful degradation, and frozen-binary transliterator smoke coverage (rounds 4–5). The work is solid.

event=COMMENT because GitHub blocks self-APPROVE; treat the framing as approval-in-spirit per §15.6.

narugo1992

@narugo1992
narugo1992 merged commit 6a15de2 into main May 12, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant